/**
 * Copyright (c), 上海沃恩信息科技有限公司.
 * All rights reserved.
 * Licensed under BSD (https://www.pittypat.work/bsd.html).
 */

package pittypat.web;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import javax.websocket.CloseReason.CloseCodes;
import javax.websocket.Session;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import pittypat.IUser;
import pittypat.Json;
import pittypat.configuration.PittypatConfig;

/**
 * 用于上传文件的 WebSocket。
 */
public final class UploadSocket extends WebSocket {
	private static final Logger LOGGER = LogManager.getLogger(UploadSocket.class);

	/**
	 * 上传文件的子协议，值为：upload.pittypat。
	 * <p>
	 * 如果是通过 WebSocket 上传文件，客户端需要在握手请求中指定该协议，否则服务器不会将请求看作是上传文件，而是看作通过 WebSocket
	 * 请求某一个功能。
	 */
	public static final String UPLOAD_SUB_PROTOCOL = "upload.pittypat";

	// 已经解析的所有文件的信息列表
	private List<FileInfo> fileInfos;
	// 已经上传成功的文件的集合
	private List<UploadedFile> files;
	// 当前正在上传文件的位置
	private int currentFileIndex;
	// 当前正在上传文件的信息
	private FileInfo currentFileInfo;
	// 当前已经创建的物理文件
	private FileOutputStream stream;
	// 当前正在接收的文件的虚拟路径
	private String fileVirtualPath;
	// 当前正在接收的文件的物理路径
	private String filePhysicalPath;
	// 当前文件已经接收的字节数
	private long currentFileRead;
	// 所有文件是否全部接收完毕
	private boolean allDone;

	/**
	 * 表示要上传的文件的文件信息。
	 * <p>
	 * 该文件信息是从握手请求的查询字符串中解析得到的。
	 */
	private static final class FileInfo {
		// 文件的原始名称
		public String name;
		// 文件的扩展名称
		public String extension;
		// 文件的尺寸
		public long size;

		/**
		 * 使用文件名称和文件大小初始化 FileInfo 类的新实例。
		 * 
		 * @param name
		 *            文件名称。
		 * @param size
		 *            文件大小。
		 */
		public FileInfo(String name, long size) {
			this.name = name == null ? "" : name;
			this.extension = Path.getExtension(this.name).toLowerCase();
			this.size = size < 0 ? 0 : size;
		}

		// 返回数字的字符串格式，如果不足两位，在前面补 '0'
		private static String padString2(int num) {
			String s = "" + num;
			if (s.length() >= 2) {
				return s;
			}

			return "0" + s;
		}

		// 返回数字的字符串格式，如果不足三位，在前面补 '0'
		private static String padString3(int num) {
			String s = "" + num;
			if (s.length() >= 3) {
				return s;
			}

			if (s.length() == 2) {
				return "0" + s;
			}
			return "00" + s;
		}

		// 返回数字的字符串格式，如果不足四位，在前面补 '0'
		private static String padString4(int num) {
			String s = "" + num;
			if (s.length() >= 4) {
				return s;
			}

			if (s.length() == 3) {
				return "0" + s;
			} else if (s.length() == 2) {
				return "00" + s;
			}
			return "000" + s;
		}

		/**
		 * 根据配置文件中功能的虚拟路径模板创建文件的虚拟路径。
		 * 
		 * @param template
		 *            配置文件中 uploadPath 定义的路径模板。
		 * @param userId
		 *            当前用户的 Id，如果是匿名用户则为 null。
		 * @param ext
		 *            文件的扩展名称。
		 * @return 将模板中参数替换为具体值后的虚拟路径。
		 */
		public String createVirtualPath(String template, UUID userId, String ext) {
			if (template == null || template.length() == 0) {
				return "";
			}

			LocalDateTime now = LocalDateTime.now();
			String s = template;

			// 如果是匿名用户，模板中的 "{userid}" 将保留原样
			if (userId != null) {
				s = s.replaceAll("(?i)\\{userid\\}", userId.toString().replaceAll("-", ""));
			}

			s = s.replaceAll("(?i)\\{ext\\}", ext == null ? "" : ext);
			s = s.replaceAll("(?i)\\{uuid\\}", UUID.randomUUID().toString().replaceAll("-", ""));
			s = s.replaceAll("(?i)\\{year\\}", padString4(now.getYear()));
			s = s.replaceAll("(?i)\\{month\\}", padString2(now.getMonthValue()));
			s = s.replaceAll("(?i)\\{day\\}", padString2(now.getDayOfMonth()));
			s = s.replaceAll("(?i)\\{hour\\}", padString2(now.getHour()));
			s = s.replaceAll("(?i)\\{minute\\}", padString2(now.getMinute()));
			s = s.replaceAll("(?i)\\{second\\}", padString2(now.getSecond()));
			s = s.replaceAll("(?i)\\{ms\\}", padString3((int) (now.getNano() / 1000000)));

			// 将路径中的 '\' 替换为 '/'
			return s.replace('\\', '/');
		}
	}

	/**
	 * 表示已经接收和保存成功的上传文件。
	 */
	private static final class UploadedFile {
		// 文件的物理路径
		public String physicalPath;

		/**
		 * 使用文件的物理路径初始化 UploadFile 的新实例。
		 * 
		 * @param physicalPath
		 */
		public UploadedFile(String physicalPath) {
			this.physicalPath = physicalPath == null ? "" : physicalPath;
		}

		// 删除目标物理路径上的文件，当文件上传失败时使用
		public void delete() {
			try {
				new File(this.physicalPath).delete();
			} catch (Exception e) {
			}
		}

		// 删除指定集合中的所有文件，当文件上传失败时使用
		public static void delete(List<UploadedFile> files) {
			if (files != null && files.size() > 0) {
				for (UploadedFile file : files) {
					file.delete();
				}
			}
		}
	}

	/**
	 * 使用当前会话初始化 UploadSocket 的新实例。
	 * 
	 * @param session
	 *            一个 Session 对象。
	 */
	public UploadSocket(Session session) {
		super(session);
		this.fileInfos = null;
		this.files = null;
		this.currentFileIndex = -1;
		this.currentFileInfo = null;
		this.stream = null;
		this.fileVirtualPath = "";
		this.filePhysicalPath = "";
		this.currentFileRead = 0;
		this.allDone = false;
	}

	// 从查询字符串中分析要上传的文件的信息，查询字符串格式为：
	// size={所有文件总字节数}&count={文件总数目}&n1={第一个文件名称}&z1={第一个文件大小}...
	// 一次能够上传的文件数目由服务器可以处理的查询字符串的最大长度限制。
	private String parseFileInfos(List<FileInfo> files) {
		// 文件的数目
		int count = 0;
		// 所有文件的上传总字节数
		long totalSize = 0L;

		// 查询字符串参数 size 表示要上传的所有文件的总字节数
		try {
			totalSize = Long.parseLong(this.getParameter("size"), 10);
		} catch (NumberFormatException e) {
			LOGGER.error(e.getLocalizedMessage(), e);
			totalSize = 0L;
		}

		if (totalSize < 1L) {
			return "没有提供有效的要上传文件的总字节数。\r\n没有选择任何文件，或者是一个编码错误，需要开发人员检查文件上传代码。";
		}

		// 查询字符串参数 count 表示要上传的所有文件的总字节数
		try {
			count = Integer.parseInt(this.getParameter("count"), 10);
		} catch (NumberFormatException e) {
			LOGGER.error(e.getLocalizedMessage(), e);
			count = 0;
		}

		if (count < 1) {
			return "没有提供有效的要上传文件的总数目。\r\n没有选择任何文件，或者是一个编码错误，需要开发人员检查文件上传代码。";
		}

		// 某一个文件的名称
		String name = "";
		// 某一个文件的尺寸
		long size = 0L;

		// 分析每一个文件，
		for (int i = 0; i < count; ++i) {
			// 第 i+1 个文件
			name = this.getParameter("n" + (i + 1));
			if (name == null || name.length() == 0) {
				// 没有提供有效的文件名称，将阻止任何文件上传
				files.clear();
				return "没有提供第 " + (i + 1) + " 个文件的文件名称。\r\n这可能是一个编码错误，需要开发人员检查文件上传代码。";
			}

			// 分析第 i+1 个文件的尺寸
			try {
				size = Long.parseLong(this.getParameter("z" + (i + 1)), 10);
			} catch (NumberFormatException e) {
				LOGGER.error(e.getLocalizedMessage(), e);
				size = 0L;
			}

			if (size <= 0L) {
				// 没有提供有效的尺寸，或者文件尺寸为 0，将阻止任何文件上传
				files.clear();
				return "没有提供第 " + (i + 1) + " 个文件的文件大小。\r\n这可能是一个编码错误，需要开发人员检查文件上传代码。";
			}

			// 解析到一个有效的文件信息
			files.add(new FileInfo(name, size));
		}

		// 返回空字符串表示解析成功
		return "";
	}

	// 检查指定的文件扩展名称 ext 是否是 位于 allowed 数组中，比较不区分大小写
	private static boolean isExtensionAllowed(String[] allowed, String ext) {
		for (String item : allowed) {
			if (item.equalsIgnoreCase(ext)) {
				return true;
			}
		}

		return false;
	}

	// 创建指定路径上的一个文件，首先检查目标文件夹是否存在，如果文件夹不存在，首先创建文件夹
	private static FileOutputStream createFile(String path) throws Exception {
		File file = new File(path);
		file.getParentFile().mkdirs();

		return new FileOutputStream(file);
	}

	/**
	 * 当连接打开时要执行的方法。
	 * 
	 * @return 返回一个值，该值指示操作是否已经处理完毕。如果返回值不是 0，连接将被关闭。
	 */
	@Override
	protected int onOpen() {
		try {
			// 如果之前的文件流尚未关闭，首选关闭该文件
			if (this.stream != null) {
				try {
					this.stream.close();
				} catch (IOException e) {
					LOGGER.error(e.getLocalizedMessage(), e);
				}
				this.stream = null;
			}

			// 当前用户
			IUser user = this.getRequestContext().getUser();

			// 从请求的查询字符串中解析上传文件的信息。
			List<FileInfo> fileInfos = new ArrayList<>(8);
			// 解析成功，则返回空字符串或 null，解析失败返回错误信息
			String errorMessage = parseFileInfos(fileInfos);

			// 解析文件信息失败
			if (errorMessage != null && errorMessage.length() > 0) {
				this.sendError(errorMessage);
				// 关闭 WebSocket
				return CloseCodes.CANNOT_ACCEPT.getCode();
			}

			// 没有提供任何要上传的文件
			if (fileInfos.size() == 0) {
				this.sendError("没有要上传的文件。\r\n没有选择任何文件，或者是一个编码错误，需要开发人员检查文件上传代码。");
				// 关闭 WebSocket
				return CloseCodes.CANNOT_ACCEPT.getCode();
			}

			// 获取此前已经保存在请求中的 FunctionInfo
			FunctionInfo funcInfo = this.getRequestContext().getFuncInfo();
			// 目标功能对应的上传文件保存路径
			String uploadPath = funcInfo.meta.getConfig().getUploadPath();
			// 目标功能对应的最大上传文件的尺寸
			long maxUploadSize = funcInfo.getMaxUploadSize();

			if (uploadPath.length() == 0 || maxUploadSize < 1L) {
				// 不允许上传文件
				this.sendError(
						"没有为功能 " + funcInfo.path + " 指定上传文件的保存位置，或者没有定义最大尺寸，或者最大尺寸小于等于 0。\r\n这是一个配置错误，需要系统管理人员调整配置文件。");
				// 关闭 WebSocket
				return CloseCodes.CANNOT_ACCEPT.getCode();
			}

			// 检查文件尺寸是否超过限制
			for (FileInfo info : fileInfos) {
				if (info.size > maxUploadSize) {
					this.sendError("文件 " + info.name + " 的长度 " + (info.size / 1024) + " KB 超过了允许的最大长度 "
							+ (maxUploadSize / 1024) + " KB。\r\n请选择更小的文件，或者询问系统管理人员，通过修改配置文件的方式来增加文件的最大长度。");
					// 关闭 WebSocket
					return CloseCodes.CANNOT_ACCEPT.getCode();
				}
			}

			// 检查扩展名称是否满足要求
			String[] uploadExtensions = funcInfo.meta.getConfig().getUploadExtensions();
			if (uploadExtensions == null || uploadExtensions.length == 0) {
				this.sendError("没有为功能 " + funcInfo.path + " 指定上传文件的类型，将阻止上传任何类型的文件。\r\n这是一个配置错误，需要系统管理人员调整配置文件。");

				// 关闭 WebSocket
				return CloseCodes.CANNOT_ACCEPT.getCode();
			}

			// 检查扩展名称是否满足要求
			for (FileInfo info : fileInfos) {
				if (!isExtensionAllowed(uploadExtensions, info.extension)) {
					this.sendError(
							"扩展名称为 " + info.extension + " 的文件不允许。\r\n这是一个安全选项，请选择其他类型的文件，或者将文件的扩展名称修改为允许的扩展名称。 ");
					// 关闭 WebSocket
					return CloseCodes.CANNOT_ACCEPT.getCode();
				}
			}

			// 发送第一个 uploaded 消息，通知客户端文件分片的尺寸
			this.sendAsync("{\"type\":\"uploaded\",\"size\":\""
					+ (PittypatConfig.getCurrent().getSocketDataSize() * 1024) + "\",\"url\":\"\"}");

			// 已经成功上传的文件的集合
			this.files = new ArrayList<UploadedFile>(fileInfos.size());
			// 所有文件的信息集合
			this.fileInfos = fileInfos;
			// 当前正在接收的文件的索引位置
			this.currentFileIndex = 0;
			// 当前正在接收的文件的信息
			this.currentFileInfo = this.fileInfos.get(this.currentFileIndex);
			// 创建当前正在上传文件的虚拟路径
			this.fileVirtualPath = this.currentFileInfo.createVirtualPath(funcInfo.meta.getConfig().getUploadPath(),
					user.getId(), this.currentFileInfo.extension);
			// 当前正在上传文件的物理路径
			this.filePhysicalPath = VirtualFile.mapPath(this.fileVirtualPath);
			// 当前文件已经读取的字节数
			this.currentFileRead = 0;
			// 是否所有文件上传完成
			this.allDone = false;

			// 在目标物理路径上创建文件
			try {
				this.stream = createFile(this.filePhysicalPath);
			} catch (Exception e) {
				// 确保关闭文件流
				if (this.stream != null) {
					try {
						this.stream.close();
					} catch (IOException e1) {
						LOGGER.error(e1.getLocalizedMessage(), e1);
					}
					this.stream = null;
				}
				this.sendError("无法保存上传文件 " + this.fileVirtualPath + "。\r\n这可能是一个配置错误或安全错误，联系系统管理员检查目标存储路径是否存在。 ");
				// 无法创建文件
				LOGGER.fatal("不能在上传目录中创建上传文件：" + this.filePhysicalPath + "。", e);
				// 关闭 WebSocket
				return CloseCodes.UNEXPECTED_CONDITION.getCode();
			}

			// 不能关闭连接，要将控制权交给 onBinaryMessage
			return 0;
		} catch (IOException e) {
			LOGGER.error(e.getLocalizedMessage(), e);
			return CloseCodes.UNEXPECTED_CONDITION.getCode();
		}
	}

	/**
	 * 由接收二进制消息的继承类实现，用于处理接收到的二进制消息。默认实现总是返回 NORMAL_CLOSURE。
	 * 
	 * @param data
	 *            已经接收到的二进制数据。
	 * @param last
	 *            指示当前文本是否是最后一部分。
	 * @return 返回一个值，该值指示操作是否已经处理完毕。如果返回值不是 0，连接将被关闭。
	 */
	@Override
	protected int onBinaryMessage(byte[] data, boolean last) {
		// 已经接收数据的长度
		int len = data == null ? 0 : data.length;
		// 数据在缓冲区中的起始位置，总是为 0
		int offset = 0;

		if (len == 0 || this.allDone) {
			// 连接已经断开没有接收到任何数据，或者所有文件已经上传成功
			// 确保关闭文件流
			if (this.stream != null) {
				try {
					this.stream.close();
				} catch (IOException e1) {
					LOGGER.error(e1.getLocalizedMessage(), e1);
				}
				this.stream = null;
			}

			// 连接已经断开或其他错误导致没有接收到任何数据
			if (len == 0 || !this.allDone) {
				// 尚未完成所有文件的上传，删除已经成功接收的文件
				UploadedFile.delete(this.files);
				LOGGER.error("尚未完成所有文件上传时，WebSocket 连接被关闭。");
			}

			this.allDone = true;
			this.currentFileIndex = -1;
			this.currentFileInfo = null;
			this.fileInfos = null;
			this.files = null;

			// 所有文件已经接收完毕，正常关闭连接
			return CloseCodes.NORMAL_CLOSURE.getCode();
		}

		try {
			// 已经提前接收的下一个文件的数据长度
			int preload = 0;
			// 读取的数据中当前文件的数据长度
			int fileContentBytes = 0;

			if (this.currentFileRead < this.currentFileInfo.size) {
				// 刚刚到达的数据中包含了当前文件的数据
				if (this.currentFileRead + len <= this.currentFileInfo.size) {
					// 刚刚收到的数据全部是当前文件的内容
					fileContentBytes = len;
				} else {
					// 刚刚收到的数据中，仅有部分数据属于当前文件
					fileContentBytes = (int) ((this.currentFileRead + len) - this.currentFileInfo.size);
				}
			} else {
				// 刚刚收到的数据没有任何当前文件的内容，也就是当前文件的内容此前已经全部接收完毕
				fileContentBytes = 0;
			}

			if (fileContentBytes > 0) {
				// 将读取的数据写入当前文件
				this.stream.write(data, offset, fileContentBytes);
			}

			// 累计已经接收的当前文件的字节数
			this.currentFileRead += fileContentBytes;
			// 排除当前文件内容后，刚刚接收数据的剩余部分属于下一个文件
			preload = len - fileContentBytes;

			if (this.currentFileRead >= this.currentFileInfo.size) {
				// 当前文件已经上传完毕，将刚刚上传的文件添加到集合
				this.files.add(new UploadedFile(this.filePhysicalPath));

				// 当前文件已经读取完毕，关闭文件流，准备读取下一个文件
				this.stream.flush();
				this.stream.close();
				this.stream = null;

				// 发送 uploaded 消息，向客户端返回刚刚保存成功的文件的虚拟路径
				this.sendAsync("{\"type\":\"uploaded\",\"size\":\"" + fileContentBytes + "\",\"url\":\""
						+ Json.escape(this.fileVirtualPath) + "\"}");

				// 指向下一个文件
				++this.currentFileIndex;
				if (this.currentFileIndex >= this.fileInfos.size()) {
					// 所有文件接收完毕
					this.allDone = true;
					// 所有文件已经接收完毕，正常关闭连接
					return CloseCodes.NORMAL_CLOSURE.getCode();
				}

				// 准备接收下一个文件

				// 当前用户
				IUser user = this.getRequestContext().getUser();

				// 获取此前已经保存在请求中的 FunctionInfo
				FunctionInfo funcInfo = this.getRequestContext().getFuncInfo();

				// 重置当前文件信息
				this.currentFileInfo = this.fileInfos.get(this.currentFileIndex);
				// 虚拟路径
				this.fileVirtualPath = this.currentFileInfo.createVirtualPath(funcInfo.meta.getConfig().getUploadPath(),
						user.getId(), this.currentFileInfo.extension);
				// 物理路径
				this.filePhysicalPath = VirtualFile.mapPath(this.fileVirtualPath);
				// 已经接收的文件内容长度
				this.currentFileRead = 0;

				// 在物理路径上创建文件
				this.stream = createFile(this.filePhysicalPath);

				// 如果存在已经加载的数据，写入文件
				if (preload > 0) {
					this.stream.write(data, offset + fileContentBytes, preload);
				}
			} else {
				// 文件的数据尚未全部接收完毕，发送一个 uploading 消息，继续等待下一组数据
				this.sendAsync("{\"type\":\"uploading\",\"size\":\"" + fileContentBytes + "\"}");
			}
		} catch (Exception e) {
			// 记录错误信息
			LOGGER.fatal(e.getMessage(), e);

			// 确保关闭文件流
			try {
				if (this.stream != null) {
					this.stream.close();
					this.stream = null;
				}

				// 删除已经接收成功的文件集合
				UploadedFile.delete(this.files);

				// 发送错误信息
				this.sendError(e);
			} catch (Exception e1) {
				LOGGER.error(e1.getLocalizedMessage(), e1);
			} finally {
				this.allDone = true;
			}

			// 关闭 WebSocket
			return CloseCodes.UNEXPECTED_CONDITION.getCode();
		}

		// 继续接收后续数据
		return 0;
	}

	/**
	 * 当发生错误时执行的方法。默认记录日志，发送错误信息，关闭连接。默认实现总是返回 UNEXPECTED_CONDITION。
	 * 
	 * @return 返回一个值，该值指示是否应该关闭连接。如果返回值不是 0，连接将被关闭。
	 */
	@Override
	protected int onError(Throwable error) {
		try {
			if (this.stream != null) {
				this.stream.close();
				this.stream = null;
			}

			return super.onError(error);
		} catch (Exception e) {
			LOGGER.error(e.getLocalizedMessage(), e);
			return CloseCodes.UNEXPECTED_CONDITION.getCode();
		}
	}

	/**
	 * 当连接被关闭时执行的方法。默认什么都不做。
	 */
	@Override
	protected void onClose() {
		try {
			if (this.stream != null) {
				this.stream.close();
				this.stream = null;
			}

			super.onClose();
		} catch (Exception e) {
			LOGGER.error(e.getLocalizedMessage(), e);
		}
	}
}
